Skip to content

fix(client): propagate transport exceptions in default message handler#2640

Open
rudi193-cmd wants to merge 7 commits into
modelcontextprotocol:mainfrom
rudi193-cmd:fix/1401-client-session-error-propagation
Open

fix(client): propagate transport exceptions in default message handler#2640
rudi193-cmd wants to merge 7 commits into
modelcontextprotocol:mainfrom
rudi193-cmd:fix/1401-client-session-error-propagation

Conversation

@rudi193-cmd
Copy link
Copy Markdown

Fixes #1401.

Problem

_default_message_handler called anyio.checkpoint() unconditionally, silently swallowing any Exception passed to it. When a transport error (e.g. httpx.ReadTimeout) arrived through the read stream, _receive_loop called continue and waited for the next message that never came — hanging all pending call_tool / send_request callers indefinitely. The error was only visible as a log line with no stack trace in user code.

This was first reported by the Strands SDK team, who worked around it by supplying a custom message_handler that re-raises (#1401).

Fix

One-line change in _default_message_handler: check isinstance(message, Exception) and re-raise.

This causes the exception to exit the async for loop in _receive_loop, hit the existing except Exception handler (which logs it), and fall through to the finally block — which closes all pending response streams with CONNECTION_CLOSED, unblocking any in-flight callers with an MCPError rather than a hang.

Custom message_handler implementations that need to suppress or transform transport exceptions can still do so by catching and not re-raising.

Tests

Four tests in tests/issues/test_1401_client_session_error_handling.py:

  • test_default_message_handler_raises_on_exception — unit test for the one-line fix
  • test_default_message_handler_checkpoints_on_notification — verifies non-exception messages still checkpoint cleanly
  • test_transport_exception_unblocks_pending_request — end-to-end: injects a transport exception while a call_tool is in flight, asserts it raises MCPError rather than hanging
  • test_custom_message_handler_receives_exception — verifies a custom handler can still suppress exceptions

🤖 Generated with Claude Code

rudi193-cmd and others added 7 commits May 18, 2026 15:15
_default_message_handler called anyio.checkpoint() unconditionally,
silently swallowing any Exception passed to it. When a transport error
(e.g. httpx.ReadTimeout) was delivered through the read stream, the
async-for in _receive_loop called `continue` and waited for the next
message that never came — hanging all pending requests indefinitely.

Fix: check isinstance(message, Exception) and re-raise so the exception
exits the async-for, hits _receive_loop's except-handler (which logs it),
and falls into the finally block that closes all pending response streams
with CONNECTION_CLOSED — unblocking any in-flight call_tool or other
send_request callers.

Custom message_handler implementations that need to suppress or transform
transport exceptions can still do so by not re-raising.

Fixes modelcontextprotocol#1401.

Co-authored-by: Sean Campbell <rudi193@gmail.com>
When a response arrives for a request ID that has no pending waiter
(e.g. the request timed out and was removed from _response_streams),
the previous code called _handle_incoming(RuntimeError(...)), which
routed through _default_message_handler, which now re-raises on any
Exception — killing the session and sending CONNECTION_CLOSED to all
remaining in-flight requests.

The unknown-ID case is non-fatal: the late response simply has nowhere
to go. Log a warning and drop it; don't propagate through the message
handler. This matches the intention of the existing warning log in
_normalize_request_id and keeps the session alive for subsequent work.

Also updated the modelcontextprotocol#1401 test module docstring to clarify that
protocol-level non-fatal errors are handled inline, not via
_default_message_handler.

Fixes regressions in:
  - test_notification_validation_error (test_88_random_error.py)
  - test_response_id_non_numeric_string_no_match (test_session.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix `TaskStatus` type annotation in `test_1401_client_session_error_handling.py` to resolve pyright errors.
- Add `# pragma: no cover` to `logging.warning` in `src/mcp/shared/session.py` to fix 100% coverage requirement.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientSession Error Handling

1 participant